var Doc = require('./doc');
var Query = require('./query');
var SnapshotVersionRequest = require('./snapshot-request/snapshot-version-request');
var SnapshotTimestampRequest = require('./snapshot-request/snapshot-timestamp-request');
var emitter = require('../emitter');
var ShareDBError = require('../error');
var types = require('../types');
var util = require('../util');
var logger = require('../logger');

function connectionState(socket) {
    if (socket.readyState === 0 || socket.readyState === 1) return 'connecting';
    return 'disconnected';
}

/**
 * Handles communication with the sharejs server and provides queries and
 * documents.
 *
 * We create a connection with a socket object
 *   connection = new sharejs.Connection(sockset)
 * The socket may be any object handling the websocket protocol. See the
 * documentation of bindToSocket() for details. We then wait for the connection
 * to connect
 *   connection.on('connected', ...)
 * and are finally able to work with shared documents
 *   connection.get('food', 'steak') // Doc
 *
 * @param socket @see bindToSocket
 */
module.exports = Connection;
function Connection(socket) {
    emitter.EventEmitter.call(this);

    // Map of collection -> id -> doc object for created documents.
    // (created documents MUST BE UNIQUE)
    this.collections = {};

    // Each query and snapshot request is created with an id that the server uses when it sends us
    // info about the request (updates, etc)
    this.nextQueryId = 1;
    this.nextSnapshotRequestId = 1;

    // Map from query ID -> query object.
    this.queries = {};

    // Map from snapshot request ID -> snapshot request
    this._snapshotRequests = {};

    // A unique message number for the given id
    this.seq = 1;

    // Equals agent.clientId on the server
    this.id = null;

    // This direct reference from connection to agent is not used internal to
    // ShareDB, but it is handy for server-side only user code that may cache
    // state on the agent and read it in middleware
    this.agent = null;

    this.debug = false;

    this.state = connectionState(socket);

    this.bindToSocket(socket);
}
emitter.mixin(Connection);

/**
 * Use socket to communicate with server
 *
 * Socket is an object that can handle the websocket protocol. This method
 * installs the onopen, onclose, onmessage and onerror handlers on the socket to
 * handle communication and sends messages by calling socket.send(message). The
 * sockets `readyState` property is used to determine the initaial state.
 *
 * @param socket Handles the websocket protocol
 * @param socket.readyState
 * @param socket.close
 * @param socket.send
 * @param socket.onopen
 * @param socket.onclose
 * @param socket.onmessage
 * @param socket.onerror
 */
Connection.prototype.bindToSocket = function(socket) {
    if (this.socket) {
        this.socket.close();
        this.socket.onmessage = null;
        this.socket.onopen = null;
        this.socket.onerror = null;
        this.socket.onclose = null;
    }

    this.socket = socket;

    // State of the connection. The corresponding events are emitted when this changes
    //
    // - 'connecting'   The connection is still being established, or we are still
    //                    waiting on the server to send us the initialization message
    // - 'connected'    The connection is open and we have connected to a server
    //                    and recieved the initialization message
    // - 'disconnected' Connection is closed, but it will reconnect automatically
    // - 'closed'       The connection was closed by the client, and will not reconnect
    // - 'stopped'      The connection was closed by the server, and will not reconnect
    var newState = connectionState(socket);
    this._setState(newState);

    // This is a helper variable the document uses to see whether we're
    // currently in a 'live' state. It is true if and only if we're connected
    this.canSend = false;

    var connection = this;

    socket.onmessage = function(event) {
        try {
            var data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
        } catch (err) {
            logger.warn('Failed to parse message', event);
            return;
        }

        if (connection.debug) logger.info('RECV', JSON.stringify(data));

        var request = { data: data };
        connection.emit('receive', request);
        if (!request.data) return;

        try {
            connection.handleMessage(request.data);
        } catch (err) {
            process.nextTick(function() {
                connection.emit('error', err);
            });
        }
    };

    socket.onopen = function() {
        connection._setState('connecting');
    };

    socket.onerror = function(err) {
        // This isn't the same as a regular error, because it will happen normally
        // from time to time. Your connection should probably automatically
        // reconnect anyway, but that should be triggered off onclose not onerror.
        // (onclose happens when onerror gets called anyway).
        connection.emit('connection error', err);
    };

    socket.onclose = function(reason) {
        // node-browserchannel reason values:
        //   'Closed' - The socket was manually closed by calling socket.close()
        //   'Stopped by server' - The server sent the stop message to tell the client not to try connecting
        //   'Request failed' - Server didn't respond to request (temporary, usually offline)
        //   'Unknown session ID' - Server session for client is missing (temporary, will immediately reestablish)

        if (reason === 'closed' || reason === 'Closed') {
            connection._setState('closed', reason);
        } else if (reason === 'stopped' || reason === 'Stopped by server') {
            connection._setState('stopped', reason);
        } else {
            connection._setState('disconnected', reason);
        }
    };
};

/**
 * @param {object} message
 * @param {String} message.a action
 */
Connection.prototype.handleMessage = function(message) {
    var err = null;
    if (message.error) {
        // wrap in Error object so can be passed through event emitters
        err = new Error(message.error.message);
        err.code = message.error.code;
        // Add the message data to the error object for more context
        err.data = message;
        delete message.error;
    }
    // Switch on the message action. Most messages are for documents and are
    // handled in the doc class.
    switch (message.a) {
        case 'init':
            // Client initialization packet
            if (message.protocol !== 1) {
                err = new ShareDBError(4019, 'Invalid protocol version');
                return this.emit('error', err);
            }
            if (types.map[message.type] !== types.defaultType) {
                err = new ShareDBError(4020, 'Invalid default type');
                return this.emit('error', err);
            }
            if (typeof message.id !== 'string') {
                err = new ShareDBError(4021, 'Invalid client id');
                return this.emit('error', err);
            }
            this.id = message.id;

            this._setState('connected');
            return;

        case 'qf':
            var query = this.queries[message.id];
            if (query) query._handleFetch(err, message.data, message.extra);
            return;
        case 'qs':
            var query = this.queries[message.id];
            if (query) query._handleSubscribe(err, message.data, message.extra);
            return;
        case 'qu':
            // Queries are removed immediately on calls to destroy, so we ignore
            // replies to query unsubscribes. Perhaps there should be a callback for
            // destroy, but this is currently unimplemented
            return;
        case 'q':
            // Query message. Pass this to the appropriate query object.
            var query = this.queries[message.id];
            if (!query) return;
            if (err) return query._handleError(err);
            if (message.diff) query._handleDiff(message.diff);
            if (message.hasOwnProperty('extra')) query._handleExtra(message.extra);
            return;

        case 'bf':
            return this._handleBulkMessage(message, '_handleFetch');
        case 'bs':
            return this._handleBulkMessage(message, '_handleSubscribe');
        case 'bu':
            return this._handleBulkMessage(message, '_handleUnsubscribe');

        case 'nf':
        case 'nt':
            return this._handleSnapshotFetch(err, message);

        case 'f':
            var doc = this.getExisting(message.c, message.d);
            if (doc) doc._handleFetch(err, message.data);
            return;
        case 's':
            var doc = this.getExisting(message.c, message.d);
            if (doc) doc._handleSubscribe(err, message.data);
            return;
        case 'u':
            var doc = this.getExisting(message.c, message.d);
            if (doc) doc._handleUnsubscribe(err);
            return;
        case 'op':
            var doc = this.getExisting(message.c, message.d);
            if (doc) doc._handleOp(err, message);
            return;

        default:
            logger.warn('Ignoring unrecognized message', message);
    }
};

Connection.prototype._handleBulkMessage = function(message, method) {
    if (message.data) {
        for (var id in message.data) {
            var doc = this.getExisting(message.c, id);
            if (doc) doc[method](message.error, message.data[id]);
        }
    } else if (Array.isArray(message.b)) {
        for (var i = 0; i < message.b.length; i++) {
            var id = message.b[i];
            var doc = this.getExisting(message.c, id);
            if (doc) doc[method](message.error);
        }
    } else if (message.b) {
        for (var id in message.b) {
            var doc = this.getExisting(message.c, id);
            if (doc) doc[method](message.error);
        }
    } else {
        logger.error('Invalid bulk message', message);
    }
};

Connection.prototype._reset = function() {
    this.seq = 1;
    this.id = null;
    this.agent = null;
};

// Set the connection's state. The connection is basically a state machine.
Connection.prototype._setState = function(newState, reason) {
    if (this.state === newState) return;

    // I made a state diagram. The only invalid transitions are getting to
    // 'connecting' from anywhere other than 'disconnected' and getting to
    // 'connected' from anywhere other than 'connecting'.
    if (
        (newState === 'connecting' &&
            this.state !== 'disconnected' &&
            this.state !== 'stopped' &&
            this.state !== 'closed') ||
        (newState === 'connected' && this.state !== 'connecting')
    ) {
        var err = new ShareDBError(
            5007,
            'Cannot transition directly from ' + this.state + ' to ' + newState,
        );
        return this.emit('error', err);
    }

    this.state = newState;
    this.canSend = newState === 'connected';

    if (newState === 'disconnected' || newState === 'stopped' || newState === 'closed')
        this._reset();

    // Group subscribes together to help server make more efficient calls
    this.startBulk();
    // Emit the event to all queries
    for (var id in this.queries) {
        var query = this.queries[id];
        query._onConnectionStateChanged();
    }
    // Emit the event to all documents
    for (var collection in this.collections) {
        var docs = this.collections[collection];
        for (var id in docs) {
            docs[id]._onConnectionStateChanged();
        }
    }
    // Emit the event to all snapshots
    for (var id in this._snapshotRequests) {
        var snapshotRequest = this._snapshotRequests[id];
        snapshotRequest._onConnectionStateChanged();
    }
    this.endBulk();

    this.emit(newState, reason);
    this.emit('state', newState, reason);
};

Connection.prototype.startBulk = function() {
    if (!this.bulk) this.bulk = {};
};

Connection.prototype.endBulk = function() {
    if (this.bulk) {
        for (var collection in this.bulk) {
            var actions = this.bulk[collection];
            this._sendBulk('f', collection, actions.f);
            this._sendBulk('s', collection, actions.s);
            this._sendBulk('u', collection, actions.u);
        }
    }
    this.bulk = null;
};

Connection.prototype._sendBulk = function(action, collection, values) {
    if (!values) return;
    var ids = [];
    var versions = {};
    var versionsCount = 0;
    var versionId;
    for (var id in values) {
        var value = values[id];
        if (value == null) {
            ids.push(id);
        } else {
            versions[id] = value;
            versionId = id;
            versionsCount++;
        }
    }
    if (ids.length === 1) {
        var id = ids[0];
        this.send({ a: action, c: collection, d: id });
    } else if (ids.length) {
        this.send({ a: 'b' + action, c: collection, b: ids });
    }
    if (versionsCount === 1) {
        var version = versions[versionId];
        this.send({ a: action, c: collection, d: versionId, v: version });
    } else if (versionsCount) {
        this.send({ a: 'b' + action, c: collection, b: versions });
    }
};

Connection.prototype._sendAction = function(action, doc, version) {
    // Ensure the doc is registered so that it receives the reply message
    this._addDoc(doc);
    if (this.bulk) {
        // Bulk subscribe
        var actions = this.bulk[doc.collection] || (this.bulk[doc.collection] = {});
        var versions = actions[action] || (actions[action] = {});
        var isDuplicate = versions.hasOwnProperty(doc.id);
        versions[doc.id] = version;
        return isDuplicate;
    } else {
        // Send single doc subscribe message
        var message = { a: action, c: doc.collection, d: doc.id, v: version };
        this.send(message);
    }
};

Connection.prototype.sendFetch = function(doc) {
    return this._sendAction('f', doc, doc.version);
};

Connection.prototype.sendSubscribe = function(doc) {
    return this._sendAction('s', doc, doc.version);
};

Connection.prototype.sendUnsubscribe = function(doc) {
    return this._sendAction('u', doc);
};

Connection.prototype.sendOp = function(doc, op) {
    // Ensure the doc is registered so that it receives the reply message
    this._addDoc(doc);
    var message = {
        a: 'op',
        c: doc.collection,
        d: doc.id,
        v: doc.version,
        src: op.src,
        seq: op.seq,
    };
    if (op.op) message.op = op.op;
    if (op.create) message.create = op.create;
    if (op.del) message.del = op.del;
    this.send(message);
};

/**
 * Sends a message down the socket
 */
Connection.prototype.send = function(message) {
    if (this.debug) logger.info('SEND', JSON.stringify(message));

    this.emit('send', message);
    this.socket.send(JSON.stringify(message));
};

/**
 * Closes the socket and emits 'closed'
 */
Connection.prototype.close = function() {
    this.socket.close();
};

Connection.prototype.getExisting = function(collection, id) {
    if (this.collections[collection]) return this.collections[collection][id];
};

/**
 * Get or create a document.
 *
 * @param collection
 * @param id
 * @return {Doc}
 */
Connection.prototype.get = function(collection, id) {
    var docs = this.collections[collection] || (this.collections[collection] = {});

    var doc = docs[id];
    if (!doc) {
        doc = docs[id] = new Doc(this, collection, id);
        this.emit('doc', doc);
    }

    return doc;
};

/**
 * Remove document from this.collections
 *
 * @private
 */
Connection.prototype._destroyDoc = function(doc) {
    var docs = this.collections[doc.collection];
    if (!docs) return;

    delete docs[doc.id];

    // Delete the collection container if its empty. This could be a source of
    // memory leaks if you slowly make a billion collections, which you probably
    // won't do anyway, but whatever.
    if (!util.hasKeys(docs)) {
        delete this.collections[doc.collection];
    }
};

Connection.prototype._addDoc = function(doc) {
    var docs = this.collections[doc.collection];
    if (!docs) {
        docs = this.collections[doc.collection] = {};
    }
    if (docs[doc.id] !== doc) {
        docs[doc.id] = doc;
    }
};

// Helper for createFetchQuery and createSubscribeQuery, below.
Connection.prototype._createQuery = function(action, collection, q, options, callback) {
    var id = this.nextQueryId++;
    var query = new Query(action, this, id, collection, q, options, callback);
    this.queries[id] = query;
    query.send();
    return query;
};

// Internal function. Use query.destroy() to remove queries.
Connection.prototype._destroyQuery = function(query) {
    delete this.queries[query.id];
};

// The query options object can contain the following fields:
//
// db: Name of the db for the query. You can attach extraDbs to ShareDB and
//   pick which one the query should hit using this parameter.

// Create a fetch query. Fetch queries are only issued once, returning the
// results directly into the callback.
//
// The callback should have the signature function(error, results, extra)
// where results is a list of Doc objects.
Connection.prototype.createFetchQuery = function(collection, q, options, callback) {
    return this._createQuery('qf', collection, q, options, callback);
};

// Create a subscribe query. Subscribe queries return with the initial data
// through the callback, then update themselves whenever the query result set
// changes via their own event emitter.
//
// If present, the callback should have the signature function(error, results, extra)
// where results is a list of Doc objects.
Connection.prototype.createSubscribeQuery = function(collection, q, options, callback) {
    return this._createQuery('qs', collection, q, options, callback);
};

Connection.prototype.hasPending = function() {
    return !!(
        this._firstDoc(hasPending) ||
        this._firstQuery(hasPending) ||
        this._firstSnapshotRequest()
    );
};
function hasPending(object) {
    return object.hasPending();
}

Connection.prototype.hasWritePending = function() {
    return !!this._firstDoc(hasWritePending);
};
function hasWritePending(object) {
    return object.hasWritePending();
}

Connection.prototype.whenNothingPending = function(callback) {
    var doc = this._firstDoc(hasPending);
    if (doc) {
        // If a document is found with a pending operation, wait for it to emit
        // that nothing is pending anymore, and then recheck all documents again.
        // We have to recheck all documents, just in case another mutation has
        // been made in the meantime as a result of an event callback
        doc.once('nothing pending', this._nothingPendingRetry(callback));
        return;
    }
    var query = this._firstQuery(hasPending);
    if (query) {
        query.once('ready', this._nothingPendingRetry(callback));
        return;
    }
    var snapshotRequest = this._firstSnapshotRequest();
    if (snapshotRequest) {
        snapshotRequest.once('ready', this._nothingPendingRetry(callback));
        return;
    }
    // Call back when no pending operations
    process.nextTick(callback);
};
Connection.prototype._nothingPendingRetry = function(callback) {
    var connection = this;
    return function() {
        process.nextTick(function() {
            connection.whenNothingPending(callback);
        });
    };
};

Connection.prototype._firstDoc = function(fn) {
    for (var collection in this.collections) {
        var docs = this.collections[collection];
        for (var id in docs) {
            var doc = docs[id];
            if (fn(doc)) {
                return doc;
            }
        }
    }
};

Connection.prototype._firstQuery = function(fn) {
    for (var id in this.queries) {
        var query = this.queries[id];
        if (fn(query)) {
            return query;
        }
    }
};

Connection.prototype._firstSnapshotRequest = function() {
    for (var id in this._snapshotRequests) {
        return this._snapshotRequests[id];
    }
};

/**
 * Fetch a read-only snapshot at a given version
 *
 * @param collection - the collection name of the snapshot
 * @param id - the ID of the snapshot
 * @param version (optional) - the version number to fetch. If null, the latest version is fetched.
 * @param callback - (error, snapshot) => void, where snapshot takes the following schema:
 *
 * {
 *   id: string;         // ID of the snapshot
 *   v: number;          // version number of the snapshot
 *   type: string;       // the OT type of the snapshot, or null if it doesn't exist or is deleted
 *   data: any;          // the snapshot
 * }
 *
 */
Connection.prototype.fetchSnapshot = function(collection, id, version, callback) {
    if (typeof version === 'function') {
        callback = version;
        version = null;
    }

    var requestId = this.nextSnapshotRequestId++;
    var snapshotRequest = new SnapshotVersionRequest(
        this,
        requestId,
        collection,
        id,
        version,
        callback,
    );
    this._snapshotRequests[snapshotRequest.requestId] = snapshotRequest;
    snapshotRequest.send();
};

/**
 * Fetch a read-only snapshot at a given timestamp
 *
 * @param collection - the collection name of the snapshot
 * @param id - the ID of the snapshot
 * @param timestamp (optional) - the timestamp to fetch. If null, the latest version is fetched.
 * @param callback - (error, snapshot) => void, where snapshot takes the following schema:
 *
 * {
 *   id: string;         // ID of the snapshot
 *   v: number;          // version number of the snapshot
 *   type: string;       // the OT type of the snapshot, or null if it doesn't exist or is deleted
 *   data: any;          // the snapshot
 * }
 *
 */
Connection.prototype.fetchSnapshotByTimestamp = function(collection, id, timestamp, callback) {
    if (typeof timestamp === 'function') {
        callback = timestamp;
        timestamp = null;
    }

    var requestId = this.nextSnapshotRequestId++;
    var snapshotRequest = new SnapshotTimestampRequest(
        this,
        requestId,
        collection,
        id,
        timestamp,
        callback,
    );
    this._snapshotRequests[snapshotRequest.requestId] = snapshotRequest;
    snapshotRequest.send();
};

Connection.prototype._handleSnapshotFetch = function(error, message) {
    var snapshotRequest = this._snapshotRequests[message.id];
    if (!snapshotRequest) return;
    delete this._snapshotRequests[message.id];
    snapshotRequest._handleResponse(error, message);
};
